// (c) Copyright 2013-2014 Stephen Gold <sgold@sonic.net>
// Distributed under the terms of the GNU General Public License

/*
 This file is part of the Bats Game.

 The Bats Game is free software: you can redistribute it and/or modify
 it under the terms of the GNU General Public License as published by the
 Free Software Foundation, either version 3 of the License, or (at your
 option) any later version.

 The Bats Game is distributed in the hope that it will be useful, but
 WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 for more details.

 You should have received a copy of the GNU General Public License
 along with the Bats Game.  If not, see <http://www.gnu.org/licenses/>.
 */
package bats.scene;

import bats.Bats;
import bats.SpatialProperties;
import bats.indicator.Cursor3DIndicator;
import bats.ui.HeadsUpDisplay;
import bats.unit.UnitClass;
import com.jme3.audio.Listener;
import com.jme3.collision.CollisionResult;
import com.jme3.collision.CollisionResults;
import com.jme3.input.InputManager;
import com.jme3.input.MouseInput;
import com.jme3.input.controls.AnalogListener;
import com.jme3.input.controls.MouseAxisTrigger;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import java.util.logging.Level;
import java.util.logging.Logger;
import jme3utilities.MyCamera;
import jme3utilities.MyString;
import jme3utilities.math.MyMath;
import jme3utilities.math.MyVector3f;
import jme3utilities.ui.Signals;

/**
 * A scene component to manage the player's point of view (POV): the position of
 * the scene's render camera and audio listener.
 * <p>
 * A POV operates in three control modes: "orbiting", "first-person", and
 * "flying". Only one mode can be active at a time.
 * <p>
 * The camera's position is influenced by a specified spatial, referred to as
 * the POV's "subject".
 * <p>
 * Orbiting mode provides a 3rd-person view, keeping the subject centered in the
 * view. The subject could be a unit, or it could be the 3-D cursor. Drag the
 * middle mouse button to orbit the subject; turn the scroll wheel to dolly
 * logarithmically in or out. Hotkeys (NUM1 and NUM7) provide pre-set horizontal
 * and overhead views. A "keep visible" option is provided.
 *
 * First-person mode provides a view from the subject's viewpoint. The camera
 * moves only when the subject moves, so the subject should ideally be a
 * player-controlled unit. Drag the middle mouse button to adjust the unit's
 * pose. Turn the scroll wheel for focal zoom.
 *
 * Flying mode provides a free-flying view, not tied to any subject. Drag the
 * middle mouse button to adjust the orientation. Turn the scroll wheel to move
 * forward or backward. The move step is configurable.
 *
 * In simple applications, flyCam should be disabled before enabling any
 * instance of this class.
 *
 * I replaced com.jme3.input.ChaseCamera with orbiting mode partly because
 * ChaseCamera's dolly in/out function is linear, which becomes awkward when the
 * maxRange/minRange ratio grows large.
 *
 * @author Stephen Gold <sgold@sonic.net>
 */
public class PointOfView
        implements AnalogListener {
    // *************************************************************************
    // constants

    /**
     * rate to dolly in/out (orbiting mode only, percentage points per wheel
     * notch)
     */
    final private static float dollyInOutRate = 15f;
    /**
     * distance to the far plane of the view frustum (in meters, &gt;0)
     */
    final private static float frustumFar = 1000f;
    /**
     * distance to the near plane of the view frustum (in meters, &gt;0)
     */
    final private static float frustumNear = 0.01f;
    /**
     * vertical angle of the frustum (in degrees of arc, &gt;0)
     */
    final private static float frustumYDegrees = 45f;
    /**
     * Disorienting rotations occur when the camera looks straight up, so we
     * limit its elevation angle to just under 90 degrees. (orbiting mode only,
     * in radians)
     */
    final private static float maxElevationAngle =
            FastMath.HALF_PI - 0.001f;
    /**
     * To reduce the risk of disorientation, limit elevation angle. (orbiting
     * mode only, in radians) TODO What we mainly want to do is keep the POV
     * above ground.
     */
    final private static float minElevationAngle = -0.3f;
    /**
     * message logger for this class
     */
    final private static Logger logger =
            Logger.getLogger(PointOfView.class.getName());
    /**
     * name of the signal which controls POV movement
     */
    final public static String controlSignalName = "pov";
    /**
     * analog event string to move backward
     */
    final private static String moveBackwardEvent = "pov backward";
    /**
     * analog event string to move down
     */
    final private static String moveDownEvent = "pov down";
    /**
     * analog event string to move forward
     */
    final private static String moveForwardEvent = "pov forward";
    /**
     * analog event string to move left
     */
    final private static String moveRightEvent = "pov right";
    /**
     * analog event string to move right
     */
    final private static String moveLeftEvent = "pov left";
    /**
     * analog event string to move up
     */
    final private static String moveUpEvent = "pov up";
    // *************************************************************************
    // fields
    /**
     * true for enabled, false for disabled
     */
    private boolean enabledFlag = false;
    /**
     * if true, the subject should be kept visible in orbiting mode; if false,
     * the POV can pass through walls
     */
    private boolean keepVisibleFlag = true;
    /**
     * azimuth angle, as seen from the subject (orbiting mode only, in radians
     * east of north)
     */
    private float azimuthAngle = 0f;
    /**
     * elevation angle, as seen from the subject (orbiting mode only, in radians
     * east of north)
     */
    private float elevationAngle = 0f;
    /**
     * movement rate (flying mode only, world units per wheel notch)
     */
    private float flyRate = 1f;
    /**
     * maximum distance from the subject (orbiting mode only, in meters, &gt;0)
     */
    private float maxRange = 250f;
    /**
     * minimum distance from the subject (orbiting mode only, in meters, &gt;0)
     */
    private float minRange = 0.5f;
    /**
     * current distance from the subject (orbiting mode only, in meters, &gt;0)
     */
    private float range = maxRange;
    /**
     * scene in which this POV resides: set by constructor
     */
    final private SceneNode scene;
    /**
     * which spatial controls this POV (orbiting and first-person modes only)
     */
    private Spatial subject = null;
    /**
     * current control mode
     */
    private PovMode mode = PovMode.ORBITING;
    // *************************************************************************
    // constructors

    /**
     * Instantiate a disabled POV in orbiting mode for a specified scene.
     *
     * @param scene where the POV resides (not null)
     */
    public PointOfView(SceneNode scene) {
        super();

        assert scene != null;
        this.scene = scene;
    }
    // *************************************************************************
    // new methods exposed

    /**
     * Alter this POV's subject: effective in orbiting mode only.
     *
     * @param newSubject which spatial to make the subject (not null)
     */
    public void aim(Spatial newSubject) {
        assert newSubject != null;

        if (mode != PovMode.ORBITING) {
            return;
        }
        this.subject = newSubject;
        Vector3f subjectLocation = getSubjectLocation();
        aim(subjectLocation);
    }

    /**
     * Test whether first-person mode is possible.
     *
     * @return true if first-person mode is possible, otherwise false
     */
    public boolean canGoFirstPerson() {
        /*
         * First-person mode is only possible when the subject is a unit
         * or when there's exactly one pickable unit.
         */
        boolean result = SpatialProperties.isUnit(subject);
        if (!result) {
            Spatial pickable = scene.getWorld().getPickables().getOnlyChild();
            if (pickable != null) {
                result = SpatialProperties.isUnit(pickable);
            }
        }

        return result;
    }

    /**
     * Handle the deletion of a spatial which might be the subject.
     *
     * @param spatial which spatial is being deleted (not null)
     */
    void deleteSpatial(Spatial spatial) {
        assert spatial != null;

        if (spatial != subject) {
            return;
        }
        Cursor3DIndicator cursor3d = scene.getIndicators().getCursor3D();
        subject = cursor3d.getSpatial();
        mode = PovMode.FLYING;
    }

    /**
     * Process a mouse click in the scene: Find the nearest geometry which lies
     * "under" the mouse cursor and return it or its pickable ancestor. If none,
     * move the 3D cursor to the nearest world geometry indicated by the cursor.
     *
     * @param xy view port coordinates (not null)
     * @return a pre-existing spatial (if found) or else null
     */
    Spatial findPickable(Vector2f xy) {
        assert xy != null;

        Ray ray = makeRay(xy);
        /*
         * Trace the ray to the nearest pickable geometry.
         */
        PickablesNode pickables = scene.getWorld().getPickables();
        CollisionResults results = pickables.collideWith(ray);
        int count = results.size();
        if (count > 0) {
            CollisionResult nearest = results.getClosestCollision();
            Spatial spatial = nearest.getGeometry();
            spatial = SpatialProperties.findPickable(spatial);
            return spatial;
        }

        return null;
    }

    /**
     * Copy the world location of this POV.
     *
     * @return a new vector
     */
    public Vector3f getLocation() {
        /*
         * Camera.getLocation() returns a pre-existing vector,
         * so clone it.
         */
        Vector3f location = getCamera().getLocation().clone();
        return location;
    }

    /**
     * Read the current mode of this POV.
     */
    public PovMode getMode() {
        return mode;
    }

    /**
     * Read the current range (orbiting mode only).
     *
     * @return range to subject (in meters)
     */
    public float getRange() {
        assert mode == PovMode.ORBITING : mode;
        return range;
    }

    /**
     * Enumerate the items in the "select pov" menu.
     */
    public String[] getSelectPovItems() {
        if (canGoFirstPerson()) {
            return PovMode.selectPovItemsAll;
        } else {
            return PovMode.selectPovItems;
        }
    }

    /**
     * Access the current subject of this POV.
     *
     * @return the pre-existing instance (or null)
     */
    public Spatial getSubject() {
        return subject;
    }

    /**
     * Test whether this POV is orbiting the specified spatial.
     *
     * @param spatial which spatial (unaffected, not null)
     * @return true if this POV is in orbiting mode with the spatial as its
     * subject, otherwise false
     */
    public boolean isOrbiting(Spatial spatial) {
        assert spatial != null;

        boolean result = (spatial == subject)
                && (mode == PovMode.ORBITING);
        return result;
    }

    /**
     * Process a hotkey or button action which has just begun.
     *
     * @param words action string split into an array of words (not null)
     * @return true if the action was handled, false if it wasn't handled
     */
    boolean processAction(String[] words) {
        String verb = words[0];
        switch (verb) {
            case "aim":
                if (words.length == 1) {
                    aim();
                    return true;
                }
                break;

            case "select":
                if (words.length == 3 && "pov".equals(words[1])) {
                    setMode(words[2]);
                    return true;
                }
                break;

            case "view":
                if (words.length == 2) {
                    switch (words[1]) {
                        case "horizontal":
                            viewHorizontal();
                            return true;
                        case "overhead":
                            viewOverhead();
                            return true;
                    }
                }
        }
        return false; // not handled
    }

    /**
     * Alter the enabled status of this POV.
     *
     * The flyby camera provided by SimpleApplication should be disabled before
     * enabling any instance of this class.
     *
     * @param newState true to enable, false to disable
     */
    public void setEnabled(boolean newState) {
        if (enabledFlag && newState == false) {
            unmapButton();

        } else if (!enabledFlag && newState == true) {
            setFrustum();
            mapButton();
        }
        enabledFlag = newState;
    }

    /**
     * Alter the "keep visible" flag (which is effective only in orbiting mode).
     *
     * @param newValue true means keep the subject visible in orbiting mode,
     * false means the orbiting POV can move through walls
     */
    public void setKeepVisible(boolean newValue) {
        keepVisibleFlag = newValue;
    }

    /**
     * Alter the POV's location: not allowed in first-person mode.
     *
     * In orbiting mode, the camera gets re-oriented so as to keep it trained on
     * its subject.
     *
     * @param newLocation world coordinates (not null)
     */
    void setLocation(Vector3f newLocation) {
        assert mode != PovMode.FIRST_PERSON;
        assert newLocation != null;

        getCamera().setLocation(newLocation);
        if (mode == PovMode.ORBITING) {
            Vector3f targetLocation = subject.getWorldTranslation();
            aim(targetLocation);
        }
    }

    /**
     * Alter the POV's maximum range (which is effective only in orbiting mode).
     *
     * @param newMaxRange (in meters, &gt;0)
     */
    public void setMaxRange(float newMaxRange) {
        assert newMaxRange > 0f : newMaxRange;
        maxRange = newMaxRange;
        /*
         * Clamp range to the new limits.
         */
        range = FastMath.clamp(range, minRange, maxRange);
    }

    /**
     * Alter the POV's minimum range (which is effective only in orbiting mode).
     *
     * @param newMinRange (in meters, &gt;0)
     */
    public void setMinRange(float newMinRange) {
        assert newMinRange > 0f : newMinRange;
        minRange = newMinRange;
        /*
         * Clamp range to the new limits.
         */
        range = FastMath.clamp(range, minRange, maxRange);
    }

    /**
     * Alter the control mode.
     *
     * @param newMode (not null)
     */
    public void setMode(PovMode newMode) {
        assert newMode != null;

        if (mode == PovMode.FLYING && newMode == PovMode.ORBITING) {
            /*
             * When switching from flying to orbiting, re-aim the POV.
             */
            mode = newMode;
            Vector3f targetLocation = subject.getWorldTranslation();
            aim(targetLocation);
            return;

        } else if (newMode == PovMode.FIRST_PERSON) {
            /*
             * When switching to first-person, make sure the subject is a unit.
             */
            assert canGoFirstPerson() : subject;
            if (!SpatialProperties.isUnit(subject)) {
                subject = scene.getWorld().getPickables().getOnlyChild();
            }
            assert SpatialProperties.isUnit(subject) : subject;
        }

        mode = newMode;
    }

    /**
     * Alter the movement step of this POV (which is effective only in flying
     * mode).
     *
     * @param newRate (in world units per wheel notch, &ge;0)
     */
    public void setMoveStep(float newRate) {
        assert newRate >= 0f : newRate;

        flyRate = newRate;
    }

    /**
     * Update the position of the application's render camera and audio listener
     * to match this POV.
     */
    void update() {
        if (!enabledFlag) {
            return;
        }
        /*
         * Re-position the application's render camera.
         */
        Camera camera = getCamera();
        Vector3f newLocation;
        Quaternion newOrientation;
        switch (mode) {
            case ORBITING:
                Vector3f direction =
                        MyVector3f.fromAltAz(elevationAngle, azimuthAngle);
                direction.negateLocal();
                assert direction.isUnitVector() : direction;

                float scaleFactor = scene.getScaleFactor();
                Vector3f offset = direction.mult(-range / scaleFactor);
                Vector3f subjectLocation = getSubjectLocation();
                newLocation = subjectLocation.add(offset);
                if (keepVisibleFlag) {
                    keepVisible(newLocation, direction);
                    offset = direction.mult(-range / scaleFactor);
                    newLocation = subjectLocation.add(offset);
                }
                camera.setLocation(newLocation);
                MyCamera.look(camera, direction);
                newOrientation = camera.getRotation().clone();
                break;

            case FLYING:
                newLocation = getLocation();
                newOrientation = camera.getRotation().clone();
                break;

            case FIRST_PERSON:
                newLocation = getSubjectLocation();
                camera.setLocation(newLocation);

                newOrientation = getSubjectOrientation();
                camera.setRotation(newOrientation);
                break;

            default:
                throw new IllegalStateException();
        }
        /*
         * Re-position the audio listener to coincide with the render camera.
         */
        Listener listener = scene.getApplication().getListener();
        listener.setLocation(newLocation);
        listener.setRotation(newOrientation);
    }
    // *************************************************************************
    // AnalogListener methods

    /**
     * Process an analog event from the mouse.
     *
     * @param eventString textual description of the analog event (not null)
     * @param amount amount of the event (&ge;0)
     * @param ignored
     */
    @Override
    public void onAnalog(String eventString, float amount, float ignored) {
        assert amount >= 0f;
        logger.log(Level.INFO, "Received analog event {0} with amount={1}",
                new Object[]{
            MyString.quote(eventString), amount
        });

        switch (eventString) {
            case moveBackwardEvent:
                moveBackward(+amount);
                return;
            case moveDownEvent:
                moveUp(-amount);
                return;
            case moveForwardEvent:
                moveBackward(-amount);
                return;
            case moveLeftEvent:
                moveLeft(+amount);
                return;
            case moveRightEvent:
                moveLeft(-amount);
                return;
            case moveUpEvent:
                moveUp(+amount);
                return;
        }
        logger.log(Level.WARNING, "Analog event {0} was not handled.",
                MyString.quote(eventString));
    }
    // *************************************************************************
    // private methods

    /**
     * Re-orient this POV to the mouse cursor: effective only in orbiting mode.
     */
    private void aim() {
        InputManager inputManager = scene.getApplication().getInputManager();
        Vector2f mouseXY = inputManager.getCursorPosition();
        aim(mouseXY);
    }

    /**
     * Re-orient this POV to the specified view port coordinates: effective only
     * in orbiting mode.
     *
     * @param xy view port coordinates (not null)
     */
    private void aim(Vector2f xy) {
        assert xy != null;

        boolean includePickables = Bats.isSimulationMode()
                && mode == PovMode.ORBITING;
        Spatial spatial = findAimPoint(xy, includePickables);
        if (spatial != null) {
            aim(spatial);
        } else {
            /*
             * No geometry found: switch to flying mode.
             */
            setMode(PovMode.FLYING);
        }
    }

    /**
     * Re-orient this POV to center the specified location in the view port:
     * effective only in orbiting mode.
     * <p>
     * The POV may dolly in or out in order to maintain a reasonable distance
     * from the subject.
     *
     * @param subjectLocation world coordinates to aim at (not null)
     */
    private void aim(Vector3f subjectLocation) {
        assert subjectLocation != null;
        if (mode != PovMode.ORBITING) {
            return;
        }
        /*
         * Calculate the POV's offset relative to the subject.
         */
        Vector3f location = getLocation();
        Vector3f offset = location.subtract(subjectLocation);
        /*
         * Convert the offset to spherical coordinates.
         */
        elevationAngle = MyVector3f.altitude(offset);
        azimuthAngle = MyVector3f.azimuth(offset);
        range = offset.length() * scene.getScaleFactor();
        /*
         * Limit the range and elevation angle.
         */
        range = FastMath.clamp(range, minRange, maxRange);
        elevationAngle = FastMath.clamp(elevationAngle, minElevationAngle,
                maxElevationAngle);
    }

    /**
     * Process a left mouse click in the scene: Find the nearest geometry that
     * lies "under" the cursor. If none, move the 3D cursor to the nearest world
     * geometry and return the 3D cursor.
     *
     * Include all world geometries and update the insertion point.
     *
     * @param xy view port coordinates (not null)
     * @param prioritizePickables if true, give priority to pickable spatials
     * @return a pre-existing spatial (if found) or else null
     */
    private Spatial findAimPoint(Vector2f xy, boolean prioritizePickables) {
        assert xy != null;

        if (prioritizePickables) {
            /*
             * First, try pickables only.
             */
            Spatial spatial = findPickable(xy);
            if (spatial != null) {
                return spatial;
            }
        }
        /*
         * Trace the ray to the nearest geometry in the world.
         */
        Ray ray = makeRay(xy);
        CollisionResults results = scene.getWorld().collideWith(ray);
        /*
         * Find the first point of contact on a triangle which faces the POV.
         *
         * Collision results are sorted by increaing distance from the POV,
         * so the first result is also the nearest one.
         */
        Vector3f povLocation = getLocation();
        for (int i = 0; i < results.size(); i++) {
            /*
             * Calculate the offset from the POV to the point of contact.
             */
            CollisionResult result = results.getCollision(i);
            Vector3f contactPoint = result.getContactPoint();
            Vector3f offset = contactPoint.subtract(povLocation);
            /*
             * If the dot product of the normal with the offset is negative,
             * then the triangle faces the POV.  Move the 3D cursor
             * to this new location.
             */
            Vector3f normal = result.getContactNormal();
            float dotProduct = offset.dot(normal);
            if (dotProduct < 0f) {
                Cursor3DIndicator cursor3D =
                        scene.getIndicators().getCursor3D();
                Spatial spatial = cursor3D.setWorldLocation(contactPoint);
                return spatial;
            }
        }
        /*
         * No clickable geometry was found.
         */
        return null;
    }

    /**
     * Access the application's render camera.
     *
     * @return the pre-existing instance (not null)
     */
    private Camera getCamera() {
        Camera camera = scene.getApplication().getCamera();
        assert camera != null;
        return camera;
    }

    /**
     * Copy the world location of the subject.
     *
     * @return a new vector (not null)
     */
    private Vector3f getSubjectLocation() {
        Vector3f result;
        if (SpatialProperties.isUnit(subject)) {
            UnitClass unitClass = scene.getWorld().getUnitClass(subject);
            result = unitClass.getViewpointLocation(subject);
        } else {
            result = subject.getWorldTranslation().clone();
        }
        return result;
    }

    /**
     * Copy the world orientation of the subject.
     *
     * @return a new quaternion (not null)
     */
    private Quaternion getSubjectOrientation() {
        assert SpatialProperties.isUnit(subject) : subject;

        UnitClass unitClass = scene.getWorld().getUnitClass(subject);
        Quaternion result = unitClass.getViewpointOrientation(subject);
        return result;
    }

    /**
     * Convert a view port location into a ray in world coordinates.
     *
     * @param xy view port location (unaffected, not null)
     * @return a new Ray with its vertex on the near face of the view frustum
     */
    private Ray makeRay(Vector2f xy) {
        assert xy != null;

        Camera camera = getCamera();
        Vector3f vertex = camera.getWorldCoordinates(xy, 0f);
        Vector3f far = camera.getWorldCoordinates(xy, 1f);
        Vector3f direction = far.subtract(vertex);
        direction.normalizeLocal();

        Ray ray = new Ray(vertex, direction);
        return ray;
    }

    /**
     * Try to keep the subject visible by adjusting the range: effective only in
     * orbiting mode.
     *
     * @param povLocation proposed world coordinates of POV (not null)
     * @param viewDirection direction of view (unit vector)
     */
    private void keepVisible(Vector3f povLocation, Vector3f viewDirection) {
        assert povLocation != null;
        assert viewDirection != null;
        assert viewDirection.isUnitVector();
        assert mode == PovMode.ORBITING : mode;
        /*
         * Trace a geometry ray from the POV toward the subject.
         */
        Ray ray = new Ray(povLocation, viewDirection);
        CollisionResults results = scene.getWorld().collideWith(ray);
        /*
         * Find the furthest geometry in the world which is nearer
         * than the subject by at least minRange meters.
         */
        float furthest = 0f;
        for (int i = 0; i < results.size(); i++) {
            CollisionResult result = results.getCollision(i);
            float distanceFromCamera = result.getDistance();
            if (distanceFromCamera >= range) {
                continue;
            }
            Geometry geometry = result.getGeometry();
            if (subject instanceof Node
                    && geometry.hasAncestor((Node) subject)) {
                continue;
            }
            float distanceFromSubject = range - distanceFromCamera;
            if (distanceFromSubject > minRange) {
                if (distanceFromCamera > furthest) {
                    furthest = distanceFromCamera;
                }
            }
        }
        if (furthest > 0f) {
            range -= furthest;
        }
    }

    /**
     * Map the middle mouse button (MMB), the one which controls the POV.
     */
    private void mapButton() {
        InputManager inputManager = scene.getApplication().getInputManager();
        /*
         * Turning the mouse wheel up triggers move backward.
         */
        boolean wheelUp = true;
        MouseAxisTrigger backwardTrigger =
                new MouseAxisTrigger(MouseInput.AXIS_WHEEL, wheelUp);
        inputManager.addMapping(moveBackwardEvent, backwardTrigger);
        inputManager.addListener(this, moveBackwardEvent);
        /*
         * Turning the mouse wheel down triggers move forward.
         */
        boolean wheelDown = false;
        MouseAxisTrigger forwardTrigger =
                new MouseAxisTrigger(MouseInput.AXIS_WHEEL, wheelDown);
        inputManager.addMapping(moveForwardEvent, forwardTrigger);
        inputManager.addListener(this, moveForwardEvent);
        /*
         * Dragging up with MMB triggers move down.
         */
        boolean up = false;
        MouseAxisTrigger downTrigger =
                new MouseAxisTrigger(MouseInput.AXIS_Y, up);
        inputManager.addMapping(moveDownEvent, downTrigger);
        inputManager.addListener(this, moveDownEvent);
        /*
         * Dragging left with MMB triggers move right.
         */
        boolean left = true;
        MouseAxisTrigger leftTrigger =
                new MouseAxisTrigger(MouseInput.AXIS_X, left);
        inputManager.addMapping(moveRightEvent, leftTrigger);
        inputManager.addListener(this, moveRightEvent);
        /*
         * Dragging right with MMB triggers move left.
         */
        boolean right = false;
        MouseAxisTrigger rightTrigger =
                new MouseAxisTrigger(MouseInput.AXIS_X, right);
        inputManager.addMapping(moveLeftEvent, rightTrigger);
        inputManager.addListener(this, moveLeftEvent);
        /*
         * Dragging down with MMB triggers move up.
         */
        boolean down = true;
        MouseAxisTrigger upTrigger =
                new MouseAxisTrigger(MouseInput.AXIS_Y, down);
        inputManager.addMapping(moveUpEvent, upTrigger);
        inputManager.addListener(this, moveUpEvent);
    }

    /**
     * Move the POV forward or backward when the mouse wheel is turned. In
     * orbiting or flying mode this results in dollying in or out. In
     * first-person mode this has no effect. Effective only when the mouse
     * cursor isn't inside the HUD.
     *
     * @param amount positive to dolly out (backward or away from the subject),
     * negative to dolly in (forward or toward the subject)
     */
    private void moveBackward(float amount) {
        HeadsUpDisplay hud = HeadsUpDisplay.getCurrent();
        if (hud != null && hud.isMouseInsideElement("hud")) {
            return;
        }

        switch (mode) {
            case ORBITING:
                float rate = 1f + dollyInOutRate / 100f;
                float factor = FastMath.pow(rate, amount);
                range = FastMath.clamp(range * factor, minRange, maxRange);
                return;

            case FLYING:
                Camera camera = getCamera();
                Vector3f direction = camera.getDirection();
                direction.multLocal(amount * flyRate);
                Vector3f location = getLocation();
                location.addLocal(direction);
                camera.setLocation(location);
                return;

            case FIRST_PERSON:
                rate = 1f + dollyInOutRate / 100f;
                factor = FastMath.pow(rate, amount);
                camera = getCamera();
                MyCamera.zoom(camera, factor);
                return;
        }
        throw new IllegalStateException();
    }

    /**
     * Move the POV left or right when the middle mouse button is dragged from
     * side to side. In orbiting mode, this involves orbiting the subject in a
     * horizontal plane. In flying mode, it involves yawing (panning the camera)
     * around the vertical (Y-) axis. In first-person mode, it involves turning
     * the subject's viewpoint (and thus the POV).
     *
     * @param amount positive to orbit right/yaw left/turn left; negative to
     * orbit left/yaw right/turn right
     */
    private void moveLeft(float amount) {
        Signals signals = scene.getApplication().getSignals();
        if (!signals.test(controlSignalName)) {
            return;
        }

        switch (mode) {
            case ORBITING:
                azimuthAngle += amount;
                azimuthAngle = MyMath.standardizeAngle(azimuthAngle);
                return;

            case FLYING:
                Camera camera = getCamera();
                Quaternion rotate = new Quaternion();
                rotate.fromAngleAxis(amount, Vector3f.UNIT_Y);
                Quaternion oldRotation = camera.getRotation();
                Quaternion newRotation = rotate.mult(oldRotation);
                camera.setRotation(newRotation);
                return;

            case FIRST_PERSON:
                UnitClass unitClass = scene.getWorld().getUnitClass(subject);
                assert unitClass != null;
                unitClass.turnViewpointLeft(subject, amount);
                return;
        }
        throw new IllegalStateException();
    }

    /**
     * Move the POV up or down when the middle mouse button is dragged from side
     * to side. In orbiting mode, this involves orbiting the subject in a
     * vertical plane. In flying mode, it involves pitching (tilting the
     * camera). In first-person mode, it involves tilting the subject's
     * viewpoint (and thus the POV).
     *
     * @param amount positive to orbit up/tilt up/tilt back; negative to orbit
     * down/tilt down/tilt forward
     */
    private void moveUp(float amount) {
        Signals signals = scene.getApplication().getSignals();
        if (!signals.test(controlSignalName)) {
            return;
        }

        switch (mode) {
            case ORBITING:
                elevationAngle += amount;
                elevationAngle = FastMath.clamp(elevationAngle,
                        minElevationAngle, maxElevationAngle);
                return;

            case FLYING:
                Camera camera = getCamera();
                Quaternion rotate = new Quaternion();
                rotate.fromAngleAxis(-amount, camera.getLeft());
                Quaternion oldRotation = camera.getRotation();
                Quaternion newRotation = rotate.mult(oldRotation);
                camera.setRotation(newRotation);
                return;

            case FIRST_PERSON:
                UnitClass unitClass = scene.getWorld().getUnitClass(subject);
                assert unitClass != null;
                unitClass.tiltViewpointForward(subject, -amount);
                return;
        }
        throw new IllegalStateException();
    }

    /**
     * Alter the control mode based on a description.
     *
     * @param newModeDescription (not null)
     */
    private void setMode(String newModeDescription) {
        assert newModeDescription != null;

        PovMode newMode =
                PovMode.fromDescription(newModeDescription);
        setMode(newMode);
    }

    /**
     * Initialize the frustum of the render camera.
     */
    private void setFrustum() {
        Camera camera = getCamera();
        float aspectRatio = MyCamera.aspectRatio(camera);

        float scaleFactor = scene.getScaleFactor();
        float near = frustumNear / scaleFactor;
        float far = frustumFar / scaleFactor;
        camera.setFrustumPerspective(frustumYDegrees, aspectRatio, near, far);
    }

    /**
     * Unmap the middle mouse button, the one used to control the POV.
     */
    private void unmapButton() {
        InputManager inputManager = scene.getApplication().getInputManager();
        inputManager.deleteMapping(moveForwardEvent);
        inputManager.deleteMapping(moveBackwardEvent);
        inputManager.deleteMapping(moveDownEvent);
        inputManager.deleteMapping(moveRightEvent);
        inputManager.deleteMapping(moveLeftEvent);
        inputManager.deleteMapping(moveUpEvent);
    }

    /**
     * Switch to a horizontal view.
     */
    private void viewHorizontal() {
        switch (mode) {
            case ORBITING:
                elevationAngle = 0f;
                return;

            case FLYING:
                Camera camera = getCamera();
                Vector3f direction = camera.getDirection();
                direction.y = 0f;
                if (direction.length() == 0f) {
                    direction.x = 1f;
                } else {
                    direction.normalizeLocal();
                }
                MyCamera.look(camera, direction);
                return;

            case FIRST_PERSON:
                UnitClass unitClass = scene.getWorld().getUnitClass(subject);
                assert unitClass != null;
                unitClass.resetViewpoint(subject);
                return;
        }
        throw new IllegalStateException();
    }

    /**
     * Switch to overhead view: not effective in first-person mode.
     */
    private void viewOverhead() {
        switch (mode) {
            case ORBITING:
                elevationAngle = maxElevationAngle;
                return;

            case FLYING:
                Camera camera = getCamera();
                MyCamera.look(camera, Vector3f.UNIT_Y.negate());
                return;

            case FIRST_PERSON:
                return;
        }
        throw new IllegalStateException();
    }
}